Repository navigation
Conversation
Adds getTransition() helper, fixes Badge's reduce-motion/duration/easing, converts TextInput label float to CSS transitions, and moves SegmentedButtonItem's checkmark spring onto theme tokens.
There was a problem hiding this comment.
🔵 Needs a closer look
It changes core animation mechanics (tokenized CSS transitions + spring adoption) across widely used components like TextInput, which is cross-platform sensitive and should get final human verification.
Pull request overview
This PR continues the MD3 Expressive motion modernization by (1) introducing a shared helper for Reanimated CSS-transition styles derived from theme motion tokens, and (2) migrating/deduping several components to use those tokens and the CSS-transition tier consistently (including adopting a spring token for SegmentedButtonItem).
Changes:
- Added
getTransition(...)insrc/theme/tokens/sys/motion.tsand unit tests validating token lookup + reduce-motion behavior. - Refactored
TextInputlabel/indicator animations from imperative Reanimated timing/shared-values to render-driven CSS transitions, and added regression tests asserting the computed label styles across focus/blur. - Updated
BadgeandCheckboxto use the shared transition helper; updatedSegmentedButtonItemto use a theme spring token viatoRawSpring(...).
File summaries
| File | Description |
|---|---|
| src/theme/tokens/sys/motion.ts | Adds getTransition helper to standardize CSS transition duration/easing/property from motion tokens. |
| src/theme/tests/motion.test.ts | Adds unit coverage for getTransition token lookup, reduce-motion, and easing construction. |
| src/components/TextInput/TextInput.tsx | Removes now-unused animation handler types; consumes updated hook-driven animation state. |
| src/components/TextInput/hooks.ts | Replaces imperative label/indicator animation with boolean-state-driven CSS transition styles; wires reduce-motion via context. |
| src/components/TextInput/constants.ts | Removes timing/easing constants tied to imperative withTiming animation. |
| src/components/SegmentedButtons/SegmentedButtonItem.tsx | Replaces hardcoded spring config with theme spring token via toRawSpring. |
| src/components/Checkbox/Checkbox.tsx | Dedupes CSS transition props through getTransition. |
| src/components/Badge.tsx | Switches Badge opacity transition to tokenized getTransition + useReduceMotion. |
| src/components/tests/TextInput.test.tsx | Adds assertions for label wrapper/text styles across focus/blur in filled + outlined variants. |
Review details
- Files reviewed: 9/9 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
🔵 Needs a closer look
It changes animation mechanisms and motion-token wiring in core components (notably TextInput), where subtle platform/runtime differences are hard to fully validate from diff review alone.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/components/tests/TextInput.test.tsx:83
getLabelWrappercurrently returns the first node witharia-hidden === true, butTextInputcan render multiplearia-hiddenelements (e.g. the error icon wrapper). That makes this helper non-specific and can cause future tests to assert styles on the wrong element if anotheraria-hiddennode appears earlier in the tree.
Consider narrowing the predicate to only match the label wrapper by additionally requiring a transition style marker (e.g. transitionProperty in the style object/array), which should be unique to the animated label wrapper in these tests.
- Files reviewed: 9/9 changed files
- Comments generated: 0 new
- Review effort level: Lite
| transitionDuration: 150 * scale, | ||
| transitionProperty: 'opacity', | ||
| ...getTransition(theme, 'opacity', 'short3', 'standard', reduceMotion), | ||
| }; |
There was a problem hiding this comment.
Have you considered typing this style as CSSStyle<ViewStyle> like in Checkbox.tsx?
There was a problem hiding this comment.
Good call, done! Badge renders Animated.Text, so I've updated it to CSSStyle<TextStyle>.
There was a problem hiding this comment.
🔵 Needs a closer look
The helper’s default generic currently permits invalid transition-property names.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/theme/tokens/sys/motion.ts:142
- The
Record<string, unknown>default erasestransitionPropertyvalidation because its keys accept any string, so typos such as'opactiy'compile and produce a no-op/invalid transition at runtime. Use Reanimated's own defaultCSSTransitionPropertiesstyle type here so callers are constrained to supported style keys.
- Files reviewed: 9/9 changed files
- Comments generated: 0 new
- Review effort level: Balanced
JKobrynski
left a comment
There was a problem hiding this comment.
Just one thing from me
|
|
||
| const checkSpringConfig = React.useMemo( | ||
| () => ({ | ||
| ...toRawSpring(theme.motion.spring.slow.spatial), |
There was a problem hiding this comment.
Should the checkmark spring be fast.spatial, like Switch and FAB? M3 maps fast to small components, slow to full-screen.
- SegmentedButtonItem checkmark spring now uses fast.spatial (small component) instead of slow.spatial, matching Switch/FAB precedent - getTransition's generic no longer erases transitionProperty validation; defaults to a real style union and Badge/Checkbox now specify their own concrete style type - TextInput.test.tsx's getLabelWrapper now also requires a transitionProperty marker (via getAnimatedStyle) so it can't match an unrelated aria-hidden node
|
Found potential problems with the pull request:
|
| theme: { motion: MotionConfig }, | ||
| property: NonNullable<CSSTransitionProperties<S>['transitionProperty']>, | ||
| durationToken: keyof MotionDuration, | ||
| easingToken: keyof MotionEasing, | ||
| reduceMotion: boolean | ||
| ): Required<Pick<CSSTransitionProperties<S>, 'transitionProperty'>> & | ||
| CSSTransitionProperties<S> { | ||
| return { | ||
| transitionProperty: property, | ||
| transitionDuration: reduceMotion ? 0 : theme.motion.duration[durationToken], |
There was a problem hiding this comment.
getTransition ignores theme.animation.scale. On main, Badge used 150 * scale, so this is a regression for Badge. A theme with animation: { scale: 0 } (often used to turn off animations in tests and screenshots) still fades the Badge for 150 ms now, and scale: 2 no longer slows it down. Surface, Chip and Button still multiply their durations by theme.animation.scale, so helper users now behave differently from those components.
Multiply the token by the scale and keep the reduceMotion check (a component-level theme prop can override the scale that PaperProvider sets for reduced motion):
| theme: { motion: MotionConfig }, | |
| property: NonNullable<CSSTransitionProperties<S>['transitionProperty']>, | |
| durationToken: keyof MotionDuration, | |
| easingToken: keyof MotionEasing, | |
| reduceMotion: boolean | |
| ): Required<Pick<CSSTransitionProperties<S>, 'transitionProperty'>> & | |
| CSSTransitionProperties<S> { | |
| return { | |
| transitionProperty: property, | |
| transitionDuration: reduceMotion ? 0 : theme.motion.duration[durationToken], | |
| theme: { motion: MotionConfig; animation: { scale: number } }, | |
| property: NonNullable<CSSTransitionProperties<S>['transitionProperty']>, | |
| durationToken: keyof MotionDuration, | |
| easingToken: keyof MotionEasing, | |
| reduceMotion: boolean | |
| ): Required<Pick<CSSTransitionProperties<S>, 'transitionProperty'>> & | |
| CSSTransitionProperties<S> { | |
| return { | |
| transitionProperty: property, | |
| transitionDuration: reduceMotion | |
| ? 0 | |
| : theme.motion.duration[durationToken] * theme.animation.scale, |
motion.test.ts then needs animation: { scale: 1 } in its theme, plus a test that a scale of 0 gives a duration of 0. (via Claude, verify if it makes sense)
There was a problem hiding this comment.
Good catch, that was a real regression. Fixed it, duration is multiplied by theme.animation.scale again, same as Surface, Chip and Button. Added two tests covering scale 2x and scale 0.
| * Zeroes the duration when `reduceMotion` is set, per the CSS tier's | ||
| * reduce-motion contract (a Reanimated `ReduceMotion` option doesn't apply | ||
| * to CSS transitions). |
There was a problem hiding this comment.
The repo does not define a "CSS tier" or a "reduce-motion contract". The same goes for "the motion decision rule" in src/components/TextInput/hooks.ts line 32. A reader cannot look these up. The documented rule is the useReduceMotion JSDoc in src/theme/accessibility/ReduceMotionContext.tsx, and it says to keep opacity and color animations when reduced motion is on. This helper zeroes every property, opacity included (Badge, the Checkbox fill, the TextInput input fade). Either follow that rule or update the useReduceMotion doc. Then describe the behavior in plain words here and in hooks.ts, for example "Sets the duration to 0 when reduceMotion is true, because Reanimated's ReduceMotion option has no effect on CSS transitions." (via Claude, verify if it makes sense)
There was a problem hiding this comment.
Fair point, fixed both things. Reworded the comments so they don't reference a "CSS tier" or "decision rule" anymore and point to the useReduceMotion JSDoc instead. Also fixed the opacity cases, Badge's fade, Checkbox's fill/outline cross-fade and TextInput's input fade now pass false instead of reduceMotion, so they keep animating under reduced motion like the contract says.
| reduceMotion: reduceMotion ? ReduceMotion.Always : ReduceMotion.Never, | ||
| }); | ||
| }, [checked, checkScale, reduceMotion, showSelectedCheck]); | ||
| checkScale.value = withSpring(checked ? 1 : 0, checkSpringConfig); |
There was a problem hiding this comment.
The check animation runs on a shared value with withSpring, but it is driven by the checked prop and not by a gesture or scroll. The project rule is to use Reanimated CSS transitions (transitionProperty and transitionDuration, as in Badge.tsx and Checkbox.tsx) for state changes, and to keep shared values for gesture or scroll driven motion. Please move the check scale and the icon scale to CSS transitions on transform. Reanimated 4.3 exports linear(...), so you can keep the fast.spatial spring shape by sampling it into linear() points for transitionTimingFunction. With that change, the useMemo and the effect go away. (via Claude, verify if it makes sense)
There was a problem hiding this comment.
I'd rather keep this one as withSpring. The checkmark gets toggled by quick taps, so it's common for the animation to get interrupted mid flight and reverse. withSpring picks up from the current position and velocity when that happens, so the reversal looks physically continuous. A linear() curve sampled from the spring only matches one specific start to end run, if it gets interrupted and restarted the sampled points no longer match the remaining distance, so the motion would snap or look off on rapid re-taps.
That's basically why the original task treats MD3 spring motion as an exception to "CSS by default", it's for motion that's genuinely spring shaped and needs to handle interruption correctly, which this is, even though the trigger is a prop rather than a gesture. Happy to revisit if you feel strongly, but wanted to explain the reasoning first.
There was a problem hiding this comment.
🟡 Changes recommended
The opacity-only reduced-motion exemption is ineffective because PaperProvider still forces animation.scale to zero, causing transitions to snap; the provider override must be separated from the user-controlled multiplier and covered by a provider-backed regression test.
1 open issue
1 resolved since last review
Review effort: Balanced
| transitionDuration: reduceMotion | ||
| ? 0 | ||
| : theme.motion.duration[durationToken] * theme.animation.scale, |

Motivation
MD3 Expressive replaces easing+duration with springs where motion is spring-shaped; CSS transitions are the default otherwise. #5015 handled the library swap + CSS-transition conversion; this PR adds spring adoption + a shared
getTransitionhelper.getTransition()CSS-transition helper intheme/tokens/sys/motion.tsBadge: uses helper, fixes duration/easing/reduce-motionCheckbox: deduped onto helper, no behavior changeTextInput: label float/active-border now CSS transitionsSegmentedButtonItem: checkmark spring nowfast.spatialtoken, matchingSwitch/FABFollow-up (separate issue): some #5015 files still use imperative
withTimingfor non-spring motion, out of scope here.Related issue
Part of the MD3 motion modernization; no tracking issue yet.
Test plan
yarn typecheck/lint/jestpass (686 tests), incl. newgetTransition+ TextInput focus/blur tests. Manual verification + screenshots below.Screenshots
Badge
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-09-08.at.13.08.16.mov
Checkbox / Checkbox Item
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-09-08.at.13.10.19.mov
TextInput
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-09-08.at.12.56.43.mov
Segmented Buttons
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-09-08.at.12.58.39.mov